# frozen_string_literal: true
require 'spec_helper'

RSpec.describe Gitlab::BackgroundMigration::MigrateSharedVulnerabilityScanners, :migration do
  # `described_class` resolves to different values depending on scope
  # rubocop: disable RSpec/DescribedClass
  let(:migration) { Gitlab::BackgroundMigration::MigrateSharedVulnerabilityScanners }
  # rubocop: enable RSpec/DescribedClass

  let(:namespaces) { table(:namespaces) }
  let(:projects) { table(:projects) }
  let(:identifiers) { table(:vulnerability_identifiers) }
  let(:vulnerabilities) { table(:vulnerabilities) }
  let(:vulnerability_reads) { table(:vulnerability_reads) }
  let(:users) { table(:users) }
  let(:findings) { migration::Finding }
  let(:scanners) { migration::Scanner }

  let(:migration_attrs) do
    {
      start_id: findings.minimum(:id),
      end_id: findings.maximum(:id),
      batch_table: :vulnerability_occurrences,
      batch_column: :id,
      sub_batch_size: 100,
      pause_ms: 0,
      connection: ApplicationRecord.connection
    }
  end

  let(:namespace_a) { namespaces.create!(name: "test-1", path: "test-1") }
  let(:namespace_b) { namespaces.create!(name: "test-2", path: "test-2") }
  let(:project) { projects.create!(namespace_id: namespace_a.id, project_namespace_id: namespace_a.id) }
  let(:other_project) { projects.create!(namespace_id: namespace_b.id, project_namespace_id: namespace_b.id) }

  def scanner(project, overrides = {})
    attrs = {
      project_id: project.id,
      external_id: "starboard_trivy",
      name: "Trivy (via Starboard Operator)"
    }.merge(overrides)

    scanners.create!(attrs)
  end

  def finding(project, scanner, overrides = {})
    attrs = {
      project_id: project.id,
      scanner_id: scanner.id,
      severity: "medium",
      confidence: "unknown",
      report_type: 99,
      primary_identifier_id: identifier(project).id,
      project_fingerprint: SecureRandom.hex(20),
      location_fingerprint: SecureRandom.hex(20),
      uuid: SecureRandom.uuid,
      name: "CVE-2018-1234",
      raw_metadata: "{}",
      metadata_version: "cluster_image_scanning:1.0"
    }.merge(overrides)

    findings.create!(attrs)
  end

  def vulnerability(project, overrides = {})
    attrs = {
      title: "test",
      severity: 6, # high
      confidence: 6, # high
      report_type: 0, # sast
      description: "test",
      project_id: project.id,
      author_id: overrides.fetch(:author_id) { user.id }
    }

    vulnerabilities.create!(attrs)
  end

  def user(overrides = {})
    attrs = {
      email: "test@example.com",
      notification_email: "test@example.com",
      name: "test",
      username: "test",
      state: "active",
      projects_limit: 10
    }.merge(overrides)

    users.create!(attrs)
  end

  def identifier(project, overrides = {})
    attrs = {
      project_id: project.id,
      external_id: "CVE-2018-1234",
      external_type: "CVE",
      name: "CVE-2018-1234",
      fingerprint: SecureRandom.hex(20)
    }.merge(overrides)

    identifiers.create!(attrs)
  end

  describe described_class::Finding do
    describe ".to_process" do
      let(:project_scanner) { scanner(project) }
      let!(:cluster_image_scanning_finding) { finding(project, scanner(other_project), report_type: 7) }
      let!(:generic_finding) { finding(project, project_scanner, report_type: 99) }
      let!(:secret_detection_finding) { finding(project, project_scanner, report_type: 4) }

      subject { described_class.to_process.pluck(:id) }

      it "returns findings with report type cluster image scanning or generic" do
        expect(subject).to match_array([cluster_image_scanning_finding].map(&:id))
      end
    end
  end

  describe described_class::Scanner do
    describe "::find_or_create_id_for" do
      let!(:existing_finding) { finding(project, existing_scanner) }

      subject { described_class.find_or_create_id_for(existing_finding) }

      context "when finding has matching scanner" do
        let(:existing_scanner) { scanner(project) }

        it "does not create a new scanner" do
          expect { subject }.not_to change(scanners, :count)
        end

        it "returns the scanner ID" do
          expect(subject).to be(existing_scanner.id)
        end
      end

      context "when finding has mismatching scanner" do
        let(:existing_scanner) { scanner(other_project) }

        it "creates a new scanner" do
          expect { subject }.to change(scanners, :count).by(1)
        end

        it "sets attributes" do
          scanner = scanners.find(subject)

          expect(scanner).to have_attributes(project_id: project.id,
                                             external_id: existing_scanner.external_id,
                                             name: existing_scanner.name,
                                             vendor: existing_scanner.vendor)
        end
      end
    end
  end

  describe "#perform" do
    let(:shared_scanner) { scanner(project) }
    let!(:correct_finding) { finding(project, shared_scanner) }
    let!(:incorrect_finding) { finding(other_project, shared_scanner) }

    subject { described_class.new(**migration_attrs).perform }

    it "creates new scanners for incorrect findings" do
      expect { subject }.to change(scanners, :count).by(1)
    end

    it "creates scanners with correct attributes" do
      subject

      scanner = scanners.find(incorrect_finding.reload.scanner_id)
      expect(scanner).to have_attributes(project_id: other_project.id,
                                         external_id: shared_scanner.external_id,
                                         name: shared_scanner.name,
                                         vendor: shared_scanner.vendor)
    end

    it "updates erroneous associations" do
      subject

      expect(incorrect_finding.reload.scanner_id).to be(scanners.last.id)
    end

    it "does not alter correct findings" do
      expect { subject }.not_to change { correct_finding.reload.attributes }
    end

    context "with existing scanner" do
      context "with matching external ID" do
        let!(:existing_scanner) { scanner(other_project) }

        it "does not create a new scanner" do
          expect { subject }.not_to change(scanners, :count)
        end

        it "reuses the scanner" do
          expect { subject }.to change { incorrect_finding.reload.scanner_id }.to(existing_scanner.id)
        end
      end

      context "with mismatching external ID" do
        let!(:existing_scanner) { scanner(other_project, external_id: "foobar") }

        it "creates a new scanner" do
          expect { subject }.to change(scanners, :count).by(1)
        end

        it "does not reuse the scanner" do
          subject

          expect(incorrect_finding.reload.scanner_id).to be(scanners.last.id)
        end
      end
    end

    context "with associated vulnerability" do
      let!(:user_a) { user(email: "test1@example.com", username: "test1") }
      let!(:user_b) { user(email: "test2@example.com", username: "test2") }

      let(:vulnerability_a) { vulnerability(project, author_id: user_a.id) }
      let(:vulnerability_b) { vulnerability(other_project, author_id: user_b.id) }

      let!(:correct_finding) { finding(project, shared_scanner, vulnerability_id: vulnerability_a.id) }
      let!(:incorrect_finding) { finding(other_project, shared_scanner, vulnerability_id: vulnerability_b.id) }

      it "updates vulnerability reads" do
        subject

        scanner_a = scanners.find_by!(project_id: project.id)
        scanner_b = scanners.find_by!(project_id: other_project.id)

        vulnerability_reads.find_by!(project_id: project.id,
                                     vulnerability_id: vulnerability_a.id,
                                     scanner_id: scanner_a.id)
        vulnerability_reads.find_by!(project_id: other_project.id,
                                     vulnerability_id: vulnerability_b.id,
                                     scanner_id: scanner_b.id)
      end
    end
  end
end
